昨天的 Day 18,我們成功讓智慧健康助理加入睡眠時間紀錄功能!
現在除了飲水量之外,也可以透過 Spring Boot API 新增睡眠紀錄,並將資料保存到 H2 Database。
例如:
{
"sleepDate": "2026-10-17",
"hours": 7.5
}
不過目前只能新增紀錄和查詢指定日期。
如果想知道自己最近一週平均睡了多久,還是得把每天的數字拿出來自己計算。
既然前幾天已經幫飲水量建立統計功能,今天當然也要讓睡眠紀錄跟上進度 XD
所以今天決定挑戰:
建立七天睡眠時間統計 API!
今天的目標是:
使用 Spring JDBC、SQL 和 Java,計算最近七天的平均睡眠時數,並找出最長與最短的睡眠紀錄。
目前睡眠紀錄已經有兩個 API:
| HTTP Method | API | 功能 |
|---|---|---|
| POST | /health/sleep |
新增睡眠紀錄 |
| GET | /health/sleep?date=2026-10-17 |
查詢指定日期紀錄 |
今天準備新增:
GET /health/sleep/statistics
讓使用者可以直接取得最近七天的睡眠統計。
例如:
{
"totalHours": 49.5,
"averageHours": 7.07,
"maxHours": 8.5,
"maxDate": "2026-10-15",
"minHours": 6.0,
"minDate": "2026-10-12",
"recordedDays": 7,
"periodDays": 7,
"unit": "hours"
}
這樣就可以一次看到:
不過今天的統計只會整理使用者輸入的睡眠時數,不會直接判斷睡眠品質。
在開始寫程式之前,我們先思考一個問題。
如果同一天有兩筆睡眠紀錄,應該怎麼計算?
例如:
10/17 晚上主要睡眠:7 小時
10/17 下午補眠:1 小時
昨天建立的資料庫允許同一天新增多筆紀錄。
所以今天決定先採用:
將同一天的睡眠紀錄加總,作為當日總睡眠時數。
例如:
7 + 1 = 8 小時
不過要注意,這個計算方式假設同一天的紀錄沒有重複輸入,而且各筆睡眠時段沒有重疊。
目前系統尚未記錄實際入睡與起床時間,所以還無法自動檢查重複時段。
另外,如果某一天沒有紀錄,我們也不能直接認定使用者完全沒有睡覺。
因此今天會區分:
七天平均紀錄時數
以及:
有紀錄的天數
避免把沒有資料誤認成實際睡眠時數為零。
昨天已經建立:
sleep_records
資料表結構如下:
CREATE TABLE IF NOT EXISTS sleep_records (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sleep_date DATE NOT NULL,
hours DOUBLE PRECISION NOT NULL,
CONSTRAINT chk_sleep_hours
CHECK (hours > 0 AND hours <= 24)
);
假設資料庫裡有:
| id | sleep_date | hours |
|---|---|---|
| 1 | 2026-10-15 | 7.0 |
| 2 | 2026-10-16 | 6.5 |
| 3 | 2026-10-17 | 7.0 |
| 4 | 2026-10-17 | 1.0 |
其中 10/17 有兩筆紀錄。
如果我們想知道每天的總睡眠時數,就需要將同一天的資料加總。
這時候又可以用到前幾天學過的 SQL:
GROUP BY
首先在 H2 Console 試著執行:
SELECT
sleep_date,
SUM(hours) AS total_hours
FROM sleep_records
GROUP BY sleep_date
ORDER BY sleep_date;
這段 SQL 的意思是:
假設原本的資料是:
10/15 → 7.0 小時
10/16 → 6.5 小時
10/17 → 7.0 小時
10/17 → 1.0 小時
查詢結果就會變成:
10/15 → 7.0 小時
10/16 → 6.5 小時
10/17 → 8.0 小時
這樣就能把多筆睡眠紀錄整理成每日統計資料。
接下來要讓 Java 接收 SQL 查詢結果。
所以今天先建立:
SleepHistoryResponse.java
放在:
dto
Package 裡面。
完整程式如下:
package com.example.healthassistant.dto;
import java.time.LocalDate;
public class SleepHistoryResponse {
private LocalDate date;
private double totalHours;
public SleepHistoryResponse(
LocalDate date,
double totalHours) {
this.date = date;
this.totalHours = totalHours;
}
public LocalDate getDate() {
return date;
}
public double getTotalHours() {
return totalHours;
}
}
這個 DTO 用來表示某一天的睡眠總時數。
例如:
{
"date": "2026-10-17",
"totalHours": 8.0
}
和昨天的 SleepResponse 不同。
昨天的 SleepResponse 代表單筆睡眠紀錄。
今天的 SleepHistoryResponse 則代表同一天加總後的結果。
接下來打開:
SleepRepository.java
加入一個新的 Method:
public List<SleepHistoryResponse> findHistory(
LocalDate startDate,
LocalDate endDate) {
String sql = """
SELECT
sleep_date,
SUM(hours) AS total_hours
FROM sleep_records
WHERE sleep_date BETWEEN ? AND ?
GROUP BY sleep_date
ORDER BY sleep_date
""";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new SleepHistoryResponse(
rs.getDate("sleep_date").toLocalDate(),
rs.getDouble("total_hours")
),
startDate,
endDate
);
}
記得加入:
import com.example.healthassistant.dto.SleepHistoryResponse;
這裡和 Day 16 的飲水量歷史查詢非常相似。
我們使用:
BETWEEN ? AND ?
查詢指定日期範圍。
再使用:
SUM(hours)
計算每日總睡眠時數。
最後透過:
jdbcTemplate.query()
將查詢結果轉換成:
List<SleepHistoryResponse>
這樣 Repository 就可以提供最近七天的睡眠紀錄給 Service。
接下來要建立統計結果的 DTO。
新增:
SleepStatisticsResponse.java
完整程式如下:
package com.example.healthassistant.dto;
import java.time.LocalDate;
public class SleepStatisticsResponse {
private double totalHours;
private double averageHours;
private double maxHours;
private LocalDate maxDate;
private double minHours;
private LocalDate minDate;
private int recordedDays;
private int periodDays;
private String unit;
public SleepStatisticsResponse(
double totalHours,
double averageHours,
double maxHours,
LocalDate maxDate,
double minHours,
LocalDate minDate,
int recordedDays,
int periodDays) {
this.totalHours = totalHours;
this.averageHours = averageHours;
this.maxHours = maxHours;
this.maxDate = maxDate;
this.minHours = minHours;
this.minDate = minDate;
this.recordedDays = recordedDays;
this.periodDays = periodDays;
this.unit = "hours";
}
public double getTotalHours() {
return totalHours;
}
public double getAverageHours() {
return averageHours;
}
public double getMaxHours() {
return maxHours;
}
public LocalDate getMaxDate() {
return maxDate;
}
public double getMinHours() {
return minHours;
}
public LocalDate getMinDate() {
return minDate;
}
public int getRecordedDays() {
return recordedDays;
}
public int getPeriodDays() {
return periodDays;
}
public String getUnit() {
return unit;
}
}
這個 DTO 會負責回傳統計結果。
| 欄位 | 說明 |
|---|---|
| totalHours | 七天累積紀錄時數 |
| averageHours | 七天平均紀錄時數 |
| maxHours | 單日最長紀錄時數 |
| maxDate | 最長紀錄日期 |
| minHours | 單日最短紀錄時數 |
| minDate | 最短紀錄日期 |
| recordedDays | 有紀錄的天數 |
| periodDays | 統計期間天數 |
| unit | 時數單位 |
接下來進入今天最重要的部分。
打開:
SleepService.java
我們要新增:
getSleepStatistics()
首先取得最近七天的紀錄:
LocalDate today = LocalDate.now();
LocalDate startDate = today.minusDays(6);
List<SleepHistoryResponse> history =
sleepRepository.findHistory(startDate, today);
和之前的飲水量統計一樣。
因為我們希望查詢:
包含今天在內的最近七個日曆日。
所以使用:
minusDays(6)
接著計算總睡眠時數:
double total = 0;
for (SleepHistoryResponse record : history) {
total += record.getTotalHours();
}
例如:
7.0 + 6.5 + 8.0 = 21.5
這樣就能得到累積睡眠時數。
接下來要計算平均值。
假設最近七天的睡眠紀錄如下:
| 日期 | 睡眠時數 |
|---|---|
| 10/11 | 7.0 |
| 10/12 | 6.0 |
| 10/13 | 7.5 |
| 10/14 | 8.0 |
| 10/15 | 8.5 |
| 10/16 | 6.5 |
| 10/17 | 6.0 |
總時數:
7 + 6 + 7.5 + 8 + 8.5 + 6.5 + 6
= 49.5 小時
平均值:
49.5 ÷ 7 ≈ 7.07 小時
在 Java 裡面可以寫:
double average = total / 7.0;
再使用:
double roundedAverage =
Math.round(average * 100.0) / 100.0;
保留小數點後兩位。
這樣就完成七天平均睡眠時數計算。
不過這裡的平均值是以七個日曆日為分母。
如果某一天沒有紀錄,該日不會增加總時數,但仍然包含在七天期間內。
所以這個數字應該理解為:
七天平均記錄睡眠時數,而不是實際平均睡眠時間。
接下來要找出:
最長睡眠紀錄
以及:
最短睡眠紀錄
和昨天的飲水統計類似,我們可以使用:
if
進行比較。
例如:
if (hours > maxHours) {
maxHours = hours;
maxDate = record.getDate();
}
如果目前的睡眠時數大於最高值,就更新最高紀錄。
最低紀錄也是相同概念:
if (hours < minHours) {
minHours = hours;
minDate = record.getDate();
}
不過如果最近七天完全沒有紀錄,就不能直接假設有一筆 0 小時的睡眠資料。
因此今天同樣會處理沒有資料的情況。
現在把剛剛的邏輯整理成一個完整 Method。
在 SleepService.java 加入:
public SleepStatisticsResponse getSleepStatistics() {
LocalDate today = LocalDate.now();
LocalDate startDate = today.minusDays(6);
List<SleepHistoryResponse> history =
sleepRepository.findHistory(startDate, today);
double total = 0;
double maxHours = 0;
double minHours = 0;
LocalDate maxDate = null;
LocalDate minDate = null;
boolean firstRecord = true;
for (SleepHistoryResponse record : history) {
double hours = record.getTotalHours();
total += hours;
if (firstRecord) {
maxHours = hours;
minHours = hours;
maxDate = record.getDate();
minDate = record.getDate();
firstRecord = false;
} else {
if (hours > maxHours) {
maxHours = hours;
maxDate = record.getDate();
}
if (hours < minHours) {
minHours = hours;
minDate = record.getDate();
}
}
}
double average = total / 7.0;
double roundedTotal =
Math.round(total * 100.0) / 100.0;
double roundedAverage =
Math.round(average * 100.0) / 100.0;
return new SleepStatisticsResponse(
roundedTotal,
roundedAverage,
maxHours,
maxDate,
minHours,
minDate,
history.size(),
7
);
}
記得在 SleepService.java 加入:
import com.example.healthassistant.dto.SleepHistoryResponse;
import com.example.healthassistant.dto.SleepStatisticsResponse;
這段程式的流程如下:
取得最近七天日期
↓
查詢 H2 Database
↓
取得每日睡眠時數
↓
計算總時數
↓
計算平均時數
↓
找出最長紀錄
↓
找出最短紀錄
↓
回傳統計 DTO
如果最高或最低值有多個日期相同,目前會保留查詢順序中最早出現的日期。
因為 SQL 已經使用:
ORDER BY sleep_date
所以結果會依照日期由舊到新排列。
Service 完成後,接下來要建立 API。
打開:
SleepController.java
加入:
@GetMapping("/statistics")
public ResponseEntity<SleepStatisticsResponse> getStatistics() {
SleepStatisticsResponse statistics =
sleepService.getSleepStatistics();
return ResponseEntity.ok(statistics);
}
記得加入:
import com.example.healthassistant.dto.SleepStatisticsResponse;
因為 Controller 已經設定:
@RequestMapping("/health/sleep")
所以完整 API 路徑就是:
GET /health/sleep/statistics
這樣就完成今天的新功能!
接下來重新啟動 Spring Boot。
HTTP Method:
GET
URL:
http://localhost:8080/health/sleep/statistics
這次不需要傳送 Request Body。
假設最近七天有以下資料:
| 日期 | 睡眠時數 |
|---|---|
| 10/11 | 7.0 |
| 10/12 | 6.0 |
| 10/13 | 7.5 |
| 10/14 | 8.0 |
| 10/15 | 8.5 |
| 10/16 | 6.5 |
| 10/17 | 6.0 |
預期回傳:
{
"totalHours": 49.5,
"averageHours": 7.07,
"maxHours": 8.5,
"maxDate": "2026-10-15",
"minHours": 6.0,
"minDate": "2026-10-12",
"recordedDays": 7,
"periodDays": 7,
"unit": "hours"
}
這樣就能一次取得最近七天的睡眠統計。
實際回傳結果會依照資料庫中的紀錄而有所不同。
接下來考慮一個特殊情況。
如果資料庫裡完全沒有最近七天的睡眠紀錄,會發生什麼事?
因為:
history.size()
等於:
0
所以:
total
會維持:
0
而:
maxDate
和:
minDate
則會維持:
null
因此預期回傳:
{
"totalHours": 0.0,
"averageHours": 0.0,
"maxHours": 0.0,
"maxDate": null,
"minHours": 0.0,
"minDate": null,
"recordedDays": 0,
"periodDays": 7,
"unit": "hours"
}
這樣程式就不會因為沒有資料而發生錯誤。
不過如果未來要建立前端介面,最好另外顯示:
最近七天尚無睡眠紀錄。
避免使用者誤以為自己真的睡了 0 小時。
經過今天的更新,專案又多了兩個 DTO。
目前的結構如下:
health-assistant
│
├── src/main/java/com/example/healthassistant
│ │
│ ├── HealthAssistantApplication.java
│ │
│ ├── controller
│ │ ├── HealthController.java
│ │ ├── WaterController.java
│ │ └── SleepController.java
│ │
│ ├── service
│ │ ├── BmiService.java
│ │ ├── WaterService.java
│ │ └── SleepService.java
│ │
│ ├── repository
│ │ ├── WaterRepository.java
│ │ └── SleepRepository.java
│ │
│ ├── dto
│ │ ├── HealthRequest.java
│ │ ├── WaterRequest.java
│ │ ├── WaterHistoryResponse.java
│ │ ├── WaterStatisticsResponse.java
│ │ ├── SleepRequest.java
│ │ ├── SleepResponse.java
│ │ ├── SleepHistoryResponse.java
│ │ └── SleepStatisticsResponse.java
│ │
│ └── exception
│ └── GlobalExceptionHandler.java
│
├── src/main/resources
│ ├── application.properties
│ └── schema.sql
│
└── pom.xml
現在系統已經有兩種統計功能:
飲水量統計
↓
WaterService
↓
WaterRepository
↓
H2 Database
以及:
睡眠時間統計
↓
SleepService
↓
SleepRepository
↓
H2 Database
這樣未來如果想加入更多健康資料,也可以沿用相似的架構。
今天完成的功能主要是:
加總
平均
最大值
最小值
這些都有明確的計算方式。
所以直接使用 Java 就可以完成。
例如:
double average = total / 7.0;
就能計算平均睡眠時數。
不需要把所有資料丟給 AI,請它幫忙算數學。
我覺得這也是這個專案很重要的一個觀念:
不是所有功能都需要 AI。
Java 比較適合負責:
而 AI 未來可以負責:
這樣分工比較清楚,也能減少不必要的 AI 呼叫。
雖然今天已經完成七天睡眠統計,但目前仍然有幾個限制。
如果某一天沒有輸入睡眠時數,系統就不會有那一天的資料。
所以七天平均紀錄量可能低於實際平均睡眠時間。
即使兩個人都睡了八小時,也不代表他們的睡眠品質相同。
目前系統沒有記錄夜間醒來次數、睡眠中斷或其他資訊。
因此不能直接用睡眠時數判斷睡眠品質。
目前我們使用:
SUM(hours)
將同一天的睡眠時數加總。
如果使用者重複輸入相同紀錄,就可能造成統計結果偏高。
未來可以加入修改、刪除及避免重複輸入的功能。
我們只計算紀錄中的數字。
不會直接根據睡眠時數提供醫療診斷或個人化治療建議。
今天成功讓智慧健康助理加入睡眠統計功能!
現在除了飲水量之外,睡眠紀錄也可以進行基本的資料分析。
今天完成的功能包括:
GROUP BY 計算每日睡眠總時數SleepHistoryResponse DTOSleepStatisticsResponse DTO目前的智慧健康助理已經具備:
智慧健康助理
│
├── BMI 計算
│ └── BMI 分類
│
├── 飲水量紀錄
│ ├── 新增紀錄
│ ├── 歷史查詢
│ └── 七天統計
│
└── 睡眠時間紀錄
├── 新增紀錄
├── 指定日期查詢
└── 七天統計
今天最大的收穫是:
將相同的資料分析概念,應用到不同種類的健康紀錄上。
經過這幾天的練習,我開始發現,建立一個系統不一定要每天學習全新的技術。
有時候更重要的是把已經學過的技術,應用到不同的功能中。
目前我們已經完成:
BMI 計算
飲水量紀錄
睡眠時間紀錄
而且飲水量和睡眠時間都有自己的統計功能。
不過這些資料目前分散在不同 API。
如果使用者想一次查看今天的健康紀錄,就需要分別呼叫不同的 API。
所以下一篇準備挑戰:
建立每日健康摘要 API!
預計會學習:
HealthSummaryResponse DTO讓智慧健康助理開始從單一功能,逐漸變成可以整合不同健康資訊的系統!